SPB Git
15.6 KB · 176 lines tsx
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { getTool, relatedTools, allPublicSlugs } from "@/lib/public/queries";5import { renderMarkdown } from "@/lib/text";6import { label, fmtDate } from "@/lib/utils";7import { Badge } from "@/components/ui/badge";8import { LogoMark } from "@/components/ui/logo-mark";9import { Breadcrumbs } from "@/components/breadcrumbs";10import { ToolCard } from "@/components/tool-card";11import { ReportForm } from "@/components/report-form";12import { ExternalLink, BookOpen, GitBranch, Receipt, Scale, ShieldCheck, Lock, AlertTriangle, Check, Minus, Scale3D } from "lucide-react";13import { APP_URL } from "@/lib/env";1415export const revalidate = 600;16export async function generateStaticParams() { try { return (await allPublicSlugs()).slice(0, 500).map((r) => ({ slug: r.slug })); } catch { return []; } }17export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {18  const t = await getTool((await params).slug); if (!t) return { title: "Not found" };19  return { title: `${t.name} — ${t.summary.slice(0, 60)}`, description: t.summary, alternates: { canonical: `/tools/${t.slug}` }, openGraph: { title: t.name, description: t.summary, type: "article", url: `${APP_URL}/tools/${t.slug}` } };20}2122function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {23  return <section id={id} aria-labelledby={`${id}-h`} className="scroll-mt-24"><h2 id={`${id}-h`} className="display h2 mb-3">{title}</h2>{children}</section>;24}25function List({ items, icon }: { items: string[]; icon?: "check" | "minus" }) {26  if (!items.length) return <p className="text-sm text-fg-3">Not documented.</p>;27  const I = icon === "minus" ? Minus : Check;28  return <ul className="grid gap-1.5 sm:grid-cols-2">{items.map((x) => <li key={x} className="flex items-start gap-2 text-[0.95rem] text-fg-2"><I className={`mt-1 h-4 w-4 shrink-0 ${icon === "minus" ? "text-warn" : "text-ok"}`} aria-hidden />{x}</li>)}</ul>;29}30function Row({ k, v }: { k: string; v: React.ReactNode }) { return <div className="border-t border-line py-2.5 first:border-t-0 first:pt-0"><dt className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-fg-3">{k}</dt><dd className="flex flex-wrap gap-1.5 text-sm text-fg-2">{v}</dd></div>; }31const chips = (xs: string[]) => (xs.length ? xs.map((x) => <Badge key={x}>{label(x)}</Badge>) : <span className="text-fg-3">—</span>);3233export default async function ToolPage({ params }: { params: Promise<{ slug: string }> }) {34  const { slug } = await params;35  const t = await getTool(slug); if (!t) notFound();36  const related = await relatedTools(t.id);37  const primary = t.categories.find((c) => c.isPrimary) ?? t.categories[0];38  const html = renderMarkdown(t.description);39  const links = [40    { href: t.homepageUrl, label: "Official site", icon: ExternalLink, field: "homepage_url" }, { href: t.docsUrl, label: "Documentation", icon: BookOpen, field: "docs_url" }, { href: t.repoUrl, label: "Repository", icon: GitBranch, field: "repo_url" },41    { href: t.pricingUrl, label: "Pricing", icon: Receipt, field: "pricing_url" }, { href: t.licenseUrl, label: "License", icon: Scale, field: "license_url" }, { href: t.privacyUrl, label: "Privacy policy", icon: Lock, field: "privacy_url" }, { href: t.securityUrl, label: "Security", icon: ShieldCheck, field: "security_url" },42  ].filter((l) => l.href);43  const lc = new Map(t.linkChecks.map((l) => [l.field, l]));44  const jsonLd = {45    "@context": "https://schema.org", "@type": "SoftwareApplication", name: t.name, url: t.homepageUrl, description: t.summary,46    ...(t.operatingSystems.length ? { operatingSystem: t.operatingSystems.map(label).join(", ") } : {}), ...(primary ? { applicationCategory: primary.name } : {}),47    ...(t.vendor ? { author: { "@type": "Organization", name: t.vendor.name, ...(t.vendor.websiteUrl ? { url: t.vendor.websiteUrl } : {}) } } : {}),48    ...(t.pricingModel.includes("free") || t.pricingModel.includes("open-source") ? { offers: { "@type": "Offer", price: "0", priceCurrency: "USD" } } : {}),49    ...(t.licenseSpdx && t.licenseUrl ? { license: t.licenseUrl } : {}),50  };51  const breadcrumbLd = { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [{ "@type": "ListItem", position: 1, name: "Home", item: APP_URL }, ...(primary?.parent ? [{ "@type": "ListItem", position: 2, name: primary.parent.name, item: `${APP_URL}/categories/${primary.parent.slug}` }] : []), ...(primary ? [{ "@type": "ListItem", position: 3, name: primary.name, item: `${APP_URL}/categories/${primary.slug}` }] : []), { "@type": "ListItem", position: 4, name: t.name, item: `${APP_URL}/tools/${t.slug}` }] };5253  return (54    <article className="mx-auto max-w-7xl px-4 py-8 sm:px-6">55      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} />56      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd).replace(/</g, "\\u003c") }} />57      <Breadcrumbs items={[...(primary?.parent ? [{ href: `/categories/${primary.parent.slug}`, label: primary.parent.name }] : []), ...(primary ? [{ href: `/categories/${primary.slug}`, label: primary.name }] : []), { label: t.name }]} />5859      <header className="mt-4 flex flex-col gap-5 md:flex-row md:items-start">60        <LogoMark name={t.name} size={72} className="rounded-2xl" />61        <div className="min-w-0 flex-1">62          <div className="flex flex-wrap items-center gap-2">63            <h1 className="display h1">{t.name}</h1>64            {t.depth === "deep" && <Badge tone="accent">In-depth entry</Badge>}65            {t.productStatus !== "active" && <Badge tone={t.productStatus === "unknown" ? "warn" : "bad"}>{label(t.productStatus)}</Badge>}66          </div>67          <p className="mt-1 text-sm text-fg-3">{t.vendor ? <>by {t.vendor.websiteUrl ? <a href={t.vendor.websiteUrl} rel="noopener noreferrer nofollow" target="_blank" className="hover:text-fg">{t.vendor.name}</a> : t.vendor.name} · </> : null}{label(t.productType)}{t.aliases.length ? <> · also known as {t.aliases.join(", ")}</> : null}</p>68          <p className="mt-3 max-w-3xl text-lg leading-snug text-fg-2">{t.summary}</p>69          <div className="mt-4 flex flex-wrap gap-1.5">70            <Badge tone={t.sourceModel === "open-source" ? "ok" : "neutral"} title="Source model / license">{t.licenseSpdx ? `${t.licenseSpdx} license` : label(t.sourceModel)}</Badge>71            {t.pricingModel.map((p) => <Badge key={p} title="Pricing model (no prices are recorded)">{label(p)}</Badge>)}72            {t.executionModes.map((e) => <Badge key={e} tone="info">{label(e)}</Badge>)}73            {t.selfHostable && <Badge tone="info">Self-hostable</Badge>}74          </div>75        </div>76        <div className="flex shrink-0 flex-col gap-2 md:w-60">77          <a href={t.homepageUrl} rel="noopener noreferrer" target="_blank" className="btn-primary">Visit official site <ExternalLink className="h-4 w-4" aria-hidden /></a>78          <Link href={`/compare?tools=${t.slug}`} className="btn-ghost" rel="nofollow" aria-label={`Compare ${t.name} with other tools`}><Scale3D className="h-4 w-4" aria-hidden /> Compare</Link>79          <p className="text-center text-[11px] text-fg-3">{lc.get("homepage_url")?.ok ? <>Link verified {fmtDate(lc.get("homepage_url")!.checkedAt)}</> : t.checkedAt ? <>Checked {fmtDate(t.checkedAt)}</> : "Link check pending"}</p>80        </div>81      </header>8283      <div className="mt-10 grid gap-10 lg:grid-cols-[1fr_20rem]">84        <div className="space-y-12">85          <Section id="overview" title="Overview">86            <div className="prose-tool" dangerouslySetInnerHTML={{ __html: html }} />87            {t.uncertaintyNote && <p className="mt-4 flex items-start gap-2 rounded-xl border border-warn/30 bg-warn/10 p-3 text-sm text-fg-2"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warn" aria-hidden /><span><strong className="text-fg">Editorial note on uncertainty.</strong> {t.uncertaintyNote}</span></p>}88          </Section>89          <Section id="capabilities" title="Capabilities"><List items={t.capabilities} /></Section>90          {t.workflows.length > 0 && <Section id="workflows" title="Typical workflows"><ol className="list-decimal space-y-1.5 pl-5 text-[0.95rem] text-fg-2">{t.workflows.map((w) => <li key={w}>{w}</li>)}</ol></Section>}91          <div className="grid gap-8 md:grid-cols-2">92            <Section id="advantages" title="Strengths"><List items={t.advantages} /></Section>93            <Section id="limitations" title="Limitations"><List items={t.limitations} icon="minus" /></Section>94          </div>95          {(t.prerequisites.length > 0 || t.audiences.length > 0) && (96            <div className="grid gap-8 md:grid-cols-2">97              {t.prerequisites.length > 0 && <Section id="prerequisites" title="Prerequisites"><List items={t.prerequisites} /></Section>}98              {t.audiences.length > 0 && <Section id="audiences" title="Who it is for"><List items={t.audiences} /></Section>}99            </div>100          )}101          {(t.alternatives.length > 0 || t.comparisonNotes) && (102            <Section id="alternatives" title="Alternatives & comparison">103              {t.comparisonNotes && <p className="prose-tool mb-4">{t.comparisonNotes}</p>}104              {t.alternatives.length > 0 && (105                <ul className="grid gap-2 sm:grid-cols-2">{t.alternatives.map((a) => (106                  <li key={a.slug} className="card p-3"><Link href={`/tools/${a.slug}`} className="font-medium hover:text-accent">{a.name}</Link><p className="mt-0.5 line-clamp-2 text-xs text-fg-3">{a.summary}</p><div className="mt-1.5 flex gap-1"><Badge>{label(a.sourceModel)}</Badge>{a.pricingModel.slice(0, 1).map((p) => <Badge key={p}>{label(p)}</Badge>)}</div></li>107                ))}</ul>108              )}109              {t.alternatives.length > 1 && <Link href={`/compare?tools=${[t.slug, ...t.alternatives.slice(0, 3).map((a) => a.slug)].join(",")}`} rel="nofollow" className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">Compare {t.name} with its alternatives →</Link>}110            </Section>111          )}112          <Section id="sources" title="Sources & verification">113            <p className="mb-3 text-sm text-fg-2">Primary sources consulted for this record. Claims are tied to the fields each source supports; the automated link checker re-verifies every URL on a schedule.</p>114            <ul className="divide-y divide-line rounded-2xl border border-line bg-bg-2">115              {t.sources.map((s) => (116                <li key={s.url} className="flex flex-col gap-1 p-3 sm:flex-row sm:items-start sm:gap-3">117                  <Badge className="shrink-0">{label(s.sourceType)}</Badge>118                  <div className="min-w-0 flex-1"><a href={s.url} rel="noopener noreferrer nofollow" target="_blank" className="break-all text-sm text-accent hover:underline">{s.title ?? s.url}</a>{s.title && <p className="truncate text-xs text-fg-3">{s.url}</p>}{s.supports.length > 0 && <p className="mt-1 text-xs text-fg-3">Supports: {s.supports.join(", ")}</p>}{s.note && <p className="mt-1 text-xs text-fg-2">{s.note}</p>}</div>119                  <span className="shrink-0 text-xs text-fg-3">checked {fmtDate(s.checkedAt)}</span>120                </li>121              ))}122            </ul>123            <dl className="mt-4 grid gap-2 text-sm sm:grid-cols-3">124              <div className="rounded-xl bg-bg-3/60 p-3"><dt className="eyebrow">Confidence</dt><dd className="mt-1 font-medium">{label(t.confidence)}</dd></div>125              <div className="rounded-xl bg-bg-3/60 p-3"><dt className="eyebrow">Reviewed</dt><dd className="mt-1 font-medium">{fmtDate(t.reviewedAt ?? t.publishedAt)}</dd></div>126              <div className="rounded-xl bg-bg-3/60 p-3"><dt className="eyebrow">Links</dt><dd className="mt-1 font-medium">{label(t.linkState)}{t.checkedAt ? ` · ${fmtDate(t.checkedAt)}` : ""}</dd></div>127            </dl>128          </Section>129          <Section id="report" title="Spotted an error?"><ReportForm slug={t.slug} /></Section>130        </div>131132        <aside className="space-y-6 lg:sticky lg:top-20 lg:self-start" aria-label="Facts and links">133          <div className="card p-4">134            <h2 className="eyebrow mb-2">Official links</h2>135            <ul className="space-y-1">{links.map((l) => { const c = lc.get(l.field); return (136              <li key={l.field}><a href={l.href!} rel="noopener noreferrer nofollow" target="_blank" className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-fg-2 hover:bg-bg-3 hover:text-fg"><l.icon className="h-4 w-4 shrink-0 text-fg-3" aria-hidden />{l.label}<span className="ml-auto" title={c ? (c.ok ? `OK ${c.statusCode ?? ""}` : `Problem ${c.statusCode ?? ""}`) : "not yet checked"}>{c ? <span className={`block h-1.5 w-1.5 rounded-full ${c.ok ? "bg-ok" : "bg-bad"}`} aria-label={c.ok ? "link verified" : "link problem"} /> : null}</span></a></li>137            ); })}</ul>138          </div>139          <div className="card p-4">140            <h2 className="eyebrow mb-1">Facts</h2>141            <dl>142              <Row k="Categories" v={t.categories.map((c) => <Link key={c.slug} href={`/categories/${c.slug}`} className="chip hover:border-accent/40 hover:text-accent" title={c.label ?? undefined}>{c.name}</Link>)} />143              <Row k="Runs" v={chips(t.executionModes)} />144              <Row k="Interfaces" v={chips(t.interfaces)} />145              <Row k="Operating systems" v={chips(t.operatingSystems)} />146              {t.deploymentModels.length > 0 && <Row k="Deployment" v={chips(t.deploymentModels)} />}147              <Row k="Source model" v={<span>{label(t.sourceModel)}{t.licenseSpdx ? ` · ${t.licenseSpdx}` : ""}</span>} />148              <Row k="Pricing model" v={<span>{t.pricingModel.map(label).join(", ")}{t.pricingCheckedAt ? <span className="block text-xs text-fg-3">checked {fmtDate(t.pricingCheckedAt)}</span> : null}</span>} />149              <Row k="Self-hostable" v={t.selfHostable === null ? "Unknown" : t.selfHostable ? "Yes" : "No"} />150              {t.languages.length > 0 && <Row k="Languages" v={chips(t.languages)} />}151              {t.dataFormats.length > 0 && <Row k="Data formats" v={chips(t.dataFormats)} />}152              {t.integrations.length > 0 && <Row k="Integrations" v={chips(t.integrations)} />}153              {t.privacyPosture && <Row k="Privacy posture" v={<span>{t.privacyPosture}</span>} />}154              {t.dataResidency && <Row k="Data residency" v={<span>{t.dataResidency}</span>} />}155            </dl>156          </div>157          {(t.tags.length > 0 || t.useCases.length > 0) && (158            <div className="card p-4">159              <h2 className="eyebrow mb-2">Tags & use cases</h2>160              <div className="flex flex-wrap gap-1.5">{t.useCases.map((u) => <Link key={u.slug} href={`/tools?use=${u.slug}`} rel="nofollow" className="chip chip-accent">{u.name}</Link>)}{t.tags.map((g) => <Link key={g.slug} href={`/tools?tag=${g.slug}`} rel="nofollow" className="chip">#{g.slug}</Link>)}</div>161            </div>162          )}163          {t.collections.length > 0 && <div className="card p-4"><h2 className="eyebrow mb-2">In collections</h2><ul className="space-y-1 text-sm">{t.collections.map((c) => <li key={c.slug}><Link href={`/collections/${c.slug}`} className="text-accent hover:underline">{c.title}</Link></li>)}</ul></div>}164        </aside>165      </div>166167      {related.length > 0 && (168        <section className="mt-16" aria-labelledby="related-h">169          <h2 id="related-h" className="display h2 mb-4">More in {primary?.name ?? "this category"}</h2>170          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{related.map((r) => <ToolCard key={r.id} t={r} />)}</div>171        </section>172      )}173    </article>174  );175}176
Files · main
Menu

Theme